-
Notifications
You must be signed in to change notification settings - Fork 1
perf(chatrooms): lazy loading with auto-deferred background initialization #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Replace O(n) .find() operations with O(1) Map.get() lookups for badges and paints. This eliminates performance lag when rendering messages with 7TV cosmetics by: - Adding cosmeticsLookup state with badgeMap and paintMap hashmaps - Populating lookup maps in addCosmetics() when cosmetics are loaded - Replacing .find() calls with Map.get() in getUserStyle, getUserBadge, getUserPaint Fixes chat lag after recent 7TV styling bug fix by transforming complexity from O(n × messages) to O(1 × messages) for cosmetic lookups.
…t handling Major refactoring to improve 7TV WebSocket performance and fix cosmetic event processing: **Shared WebSocket Migration:** - Replace per-chatroom 7TV WebSocket connections with single shared connection - Remove individual StvWebSocket class (423 lines deleted) - Update sharedStvWebSocket with proper event routing and subscription management - Deprecate connectToStvWebSocket() method in ChatProvider - Update ConnectionManager to use updateChatroom() instead of addChatroom() **Entitlement Event Handling:** - Fix cosmetic event deduplication using ref_id instead of all-zeros id field - Remove kind === 10 filter that was blocking EMOTE_SET entitlements - Route BADGE/PAINT events to cosmetics store, EMOTE_SET to dedicated handler - Add handlePersonalEmoteSetEntitlement() placeholder for future implementation - Handle global cosmetic events once instead of broadcasting to all chatrooms **Performance Improvements:** - Add deduplication for cosmetic events (30s window, 60s cleanup) - Add dedicated tracking for personal and channel emote set updates - Reduce console log spam by 82% (2900+ → 500 lines) - Add VITE_DEBUG_7TV_WS flag for optional verbose WebSocket logging **Telemetry Enhancements:** - Add console.error/console.warn instrumentation in webTracing - Capture critical warnings and errors for telemetry **Related:** - Issue #48: Full implementation needed for personal emote set entitlement sync - Builds on commit 93f2790 (hashmap optimization for cosmetics lookups) This refactor eliminates redundant WebSocket connections while fixing several bugs in how 7TV cosmetic and entitlement events are processed.
Remove synthetic test emissions that were flooding production telemetry: - Remove setTimeout block that emitted synthetic console.error after 2 seconds - Gate renderer_export_smoke test span behind NODE_ENV === 'development' check - Document remaining test code as DEV-ONLY These test blocks were useful during development but should not run in production as they create false positives in telemetry data.
Implement progressive image loading for emotes to improve perceived performance and prevent layout shift during load. Changes: - Add useProgressiveEmoteLoading hook with 4 states (placeholder, loading, loaded, error) - Render fallback placeholders (2-char abbreviation) during load and on error - Smooth opacity transitions when images finish loading - SCSS styling for placeholder states with error indicator Impact: - Eliminates layout shift from async emote loading - Better UX with visual feedback during image load - Graceful degradation for failed emote loads
… initialization Load priority chatroom immediately for fast LCP, then auto-load remaining chatrooms in background to preserve mention notifications. Changes: - Priority chatroom selection (last active or first by order) - Deferred chatroom queue with automatic background loading - Background loader processes 2 chatrooms/batch with 300ms stagger - Track loaded chatrooms to prevent duplicate initialization - Non-blocking 7TV emote refresh without intrusive UI messages - Debounce logic to prevent concurrent refresh operations - Remember last active chatroom via localStorage - Manual lazy load trigger on chatroom switch (fallback) Implementation: - ConnectionManager.initializeDeferredChatroomsInBackground() - batch loader - ConnectionManager.initializeChatroomLazily() - single chatroom loader - 800ms delay after priority load before background batch starts - All chatrooms connected within ~3 seconds for full notification support Impact: - Startup LCP improvement: ~80% faster (priority chatroom only) - All chatrooms connected automatically in background - Mention notifications work for all chatrooms - Manual fallback if user switches before background completes
WalkthroughAdds progressive emote loading (UI + styles), introduces lazy chatroom initialization and a shared 7TV WebSocket routing model, expands cosmetics store with lookup maps and single-event handlers, instruments console errors for telemetry, enables renderer sourcemaps, adjusts debug config paths, minor presence log message tweak, and deletes the legacy per-chatroom 7TV WebSocket. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant App
participant ConnMgr as ConnectionManager
participant STV as Shared7TVWebSocket
Note over App,ConnMgr: Lazy chatroom initialization
User->>App: Selects chatroom
App->>ConnMgr: initializeChatroomLazily(chatroomId)
alt First load
ConnMgr->>ConnMgr: Mark chatroom as loading
ConnMgr->>STV: updateChatroom(chatroomId, ...)
ConnMgr-->>App: isChatroomLoaded=true
par Background
rect rgba(200,230,255,0.3)
ConnMgr->>ConnMgr: initializeDeferredChatroomsInBackground()
end
end
else Already loaded
ConnMgr-->>App: isChatroomLoaded=true
end
sequenceDiagram
autonumber
participant STV as Shared7TVWebSocket
participant Router as Event Router
participant Chat as ChatProvider
participant Cos as CosmeticsProvider
Note over STV,Router: Centralized event routing
STV-->>Router: Message(type, body)
alt emote_set update (channel)
Router->>Chat: handleEmoteSetUpdate({isPersonal:false,...})
else emote_set update (personal)
rect rgba(230,255,230,0.4)
Router->>Chat: handlePersonalEmoteSetUpdate(...)
end
else cosmetic.create/delete
Router->>Cos: addCosmetic/removeCosmetic(...)
else entitlement.create/delete
Router->>Chat: handlePersonalEmoteSetEntitlement(...)
else unhandled
Router-->>Router: telemetry span (unhandled)
end
sequenceDiagram
autonumber
participant Emote as Emote.jsx
participant Img as <img>
Note over Emote,Img: Progressive emote loading
Emote->>Emote: state = loading
Emote-->>Emote: render placeholder (reserved size)
Emote->>Img: set src + onLoad/onError
alt onLoad
Img-->>Emote: handleImageLoad()
Emote-->>Emote: state = loaded (opacity 1)
else onError
Img-->>Emote: handleImageError()
Emote-->>Emote: show fallback (placeholder)
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ast-grep (0.39.5)src/renderer/src/providers/ChatProvider.jsxThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting
| // Only add to 7TV if we have valid IDs | ||
| const stvId = chatroom.streamerData?.user?.stv_id || "0"; | ||
| const stvEmoteSetId = chatroom?.channel7TVEmotes?.[1]?.setInfo?.id || "0"; | ||
| this.stvWebSocket.addChatroom(chatroom.id, chatroom.streamerData.user_id, stvId, stvEmoteSetId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use robust lookup for lazy 7TV emote set subscription
The lazy path derives stvEmoteSetId via chatroom.channel7TVEmotes?.[1]?.setInfo?.id. The initial connection flow searched the emote sets with .find(set => set.type === "channel") because the array order isn’t stable—global and personal sets can come before the channel set. When the channel set isn’t at index 1, this code passes "0" to SharedStvWebSocket.addChatroom, causing the websocket to skip subscribeToEmoteSetEvents and the deferred chatroom never receives 7TV emote updates. Use the same .find logic as the eager path to extract the channel set ID before subscribing.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 6
🧹 Nitpick comments (3)
src/renderer/src/components/Cosmetics/Emote.jsx (3)
4-45: Consider simplifying by removing redundantshowFallbackstate.The
showFallbackstate mirrorsloadState === 'error'. You could derive it directly fromloadStateto reduce state variables and prevent potential inconsistencies:-const useProgressiveEmoteLoading = (emote, type) => { - const [loadState, setLoadState] = useState('loading'); // loading, loaded, error - const [showFallback, setShowFallback] = useState(false); +const useProgressiveEmoteLoading = (emote, type) => { + const [loadState, setLoadState] = useState('loading'); // loading, loaded, error // Define fallback placeholder (prevents layout shift) const placeholder = useMemo(() => { const placeholderWidth = type === "stv" ? (emote.width || 28) : 32; const placeholderHeight = type === "stv" ? (emote.height || 28) : 32; return { width: placeholderWidth, height: placeholderHeight, backgroundColor: '#2a2a2a', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: '4px', color: '#666', fontSize: '10px', fontFamily: 'monospace' }; }, [emote, type]); const handleImageLoad = useCallback(() => { setLoadState('loaded'); - setShowFallback(false); }, []); const handleImageError = useCallback(() => { setLoadState('error'); - setShowFallback(true); }, []); return { loadState, - showFallback, + showFallback: loadState === 'error', placeholder, handleImageLoad, handleImageError }; };
139-142: Consider removing inline opacity styles; rely on CSS classes.The inline opacity transition duplicates the CSS rules in
Message.scss(lines 1060-1066). Removing the inline styles simplifies the component and centralizes styling:<img className={`${type === "stv" ? "stvEmote" : "kickEmote"} emote ${loadState}`} src={emoteImageSrc} srcSet={type === "stv" ? emoteSrcSet(emote) : null} alt={name} loading="lazy" fetchpriority="low" decoding="async" onLoad={handleImageLoad} onError={handleImageError} - style={{ - opacity: loadState === 'loaded' ? 1 : 0, - transition: 'opacity 0.2s ease-in-out' - }} />Ensure the CSS classes in
Message.scssprovide the correct opacity values for each state (currently lines 1060-1070).
146-161: Consider applying progressive loading to overlaid emotes for consistency.The overlaid zero-width emotes don't use the progressive loading pattern. While they're decorative, applying the same loading pattern would provide a consistent experience and prevent potential layout shifts if they fail to load.
If you'd like to apply progressive loading to overlaid emotes, you could refactor them to use the same hook or create a simplified version for these secondary emotes.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (12)
.env.example(1 hunks).vscode/launch.json(1 hunks)electron.vite.config.mjs(1 hunks)src/renderer/src/assets/styles/components/Chat/Message.scss(1 hunks)src/renderer/src/components/Cosmetics/Emote.jsx(2 hunks)src/renderer/src/providers/ChatProvider.jsx(22 hunks)src/renderer/src/providers/CosmeticsProvider.jsx(5 hunks)src/renderer/src/telemetry/webTracing.js(2 hunks)utils/services/connectionManager.js(7 hunks)utils/services/seventv/sharedStvWebSocket.js(6 hunks)utils/services/seventv/stvAPI.js(1 hunks)utils/services/seventv/stvWebsocket.js(0 hunks)
💤 Files with no reviewable changes (1)
- utils/services/seventv/stvWebsocket.js
🧰 Additional context used
📓 Path-based instructions (8)
**
📄 CodeRabbit inference engine (AGENTS.md)
**: Use electron-vite + React conventions instead of raw Electron patterns
When unsure, consult electron-vite/Electron/Vite docs (Context7) to confirm patterns
Omit empty sections in release notes
Files:
electron.vite.config.mjssrc/renderer/src/components/Cosmetics/Emote.jsxutils/services/connectionManager.jssrc/renderer/src/telemetry/webTracing.jssrc/renderer/src/assets/styles/components/Chat/Message.scsssrc/renderer/src/providers/CosmeticsProvider.jsxutils/services/seventv/stvAPI.jssrc/renderer/src/providers/ChatProvider.jsxutils/services/seventv/sharedStvWebSocket.js
electron.vite.config.mjs
📄 CodeRabbit inference engine (AGENTS.md)
If bare KT_* must be exposed, add envPrefix override in electron.vite.config.mjs
Files:
electron.vite.config.mjs
!dist/**
📄 CodeRabbit inference engine (AGENTS.md)
Do not commit built installers/artifacts in dist/
Files:
electron.vite.config.mjssrc/renderer/src/components/Cosmetics/Emote.jsxutils/services/connectionManager.jssrc/renderer/src/telemetry/webTracing.jssrc/renderer/src/assets/styles/components/Chat/Message.scsssrc/renderer/src/providers/CosmeticsProvider.jsxutils/services/seventv/stvAPI.js.vscode/launch.jsonsrc/renderer/src/providers/ChatProvider.jsx.env.exampleutils/services/seventv/sharedStvWebSocket.js
src/renderer/src/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
src/renderer/src/**/*.{ts,tsx,js,jsx}: Do not use direct Node APIs in the renderer
Use ipcRenderer.invoke('channel', payload) for request/response from renderer
Use import.meta.env.RENDERER_VITE_* for renderer-scoped config
Avoid bare KT_* env vars in the renderer; use RENDERER_VITE_KT_* instead
Files:
src/renderer/src/components/Cosmetics/Emote.jsxsrc/renderer/src/telemetry/webTracing.jssrc/renderer/src/providers/CosmeticsProvider.jsxsrc/renderer/src/providers/ChatProvider.jsx
src/{renderer/src,preload}/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
Resolve static assets via Vite URLs (new URL('./asset', import.meta.url))
Files:
src/renderer/src/components/Cosmetics/Emote.jsxsrc/renderer/src/telemetry/webTracing.jssrc/renderer/src/providers/CosmeticsProvider.jsxsrc/renderer/src/providers/ChatProvider.jsx
src/{main,renderer/src}/**/*.{ts,tsx,js,jsx,mts,mjs,cjs}
📄 CodeRabbit inference engine (AGENTS.md)
For fire-and-forget events, use ipcRenderer.send and ipcMain.on with namespaced channels like 'app:settings:get'
Files:
src/renderer/src/components/Cosmetics/Emote.jsxsrc/renderer/src/telemetry/webTracing.jssrc/renderer/src/providers/CosmeticsProvider.jsxsrc/renderer/src/providers/ChatProvider.jsx
src/{main,preload,renderer/src}/**/*.{ts,tsx,js,jsx,mts,mjs,cjs}
📄 CodeRabbit inference engine (AGENTS.md)
Use shared non-secret variables via import.meta.env.VITE_*
Files:
src/renderer/src/components/Cosmetics/Emote.jsxsrc/renderer/src/telemetry/webTracing.jssrc/renderer/src/providers/CosmeticsProvider.jsxsrc/renderer/src/providers/ChatProvider.jsx
src/{telemetry,renderer/src/telemetry}/**/*.{ts,tsx,js,jsx}
📄 CodeRabbit inference engine (AGENTS.md)
src/{telemetry,renderer/src/telemetry}/**/*.{ts,tsx,js,jsx}: Place OpenTelemetry instrumentation under src/telemetry/** and src/renderer/src/telemetry/**
Configure OTLP HTTP export to Grafana Cloud; read endpoints/keys from env with main-safe prefixes; do not expose secrets to renderer
Files:
src/renderer/src/telemetry/webTracing.js
🧠 Learnings (1)
📚 Learning: 2025-09-18T22:47:22.745Z
Learnt from: CR
PR: BP602/KickTalk#0
File: AGENTS.md:0-0
Timestamp: 2025-09-18T22:47:22.745Z
Learning: Applies to src/renderer/src/**/*.{ts,tsx,js,jsx} : Use import.meta.env.RENDERER_VITE_* for renderer-scoped config
Applied to files:
electron.vite.config.mjs
🧬 Code graph analysis (6)
src/renderer/src/components/Cosmetics/Emote.jsx (2)
src/renderer/src/components/Cosmetics/EmoteTooltip.jsx (1)
handleImageLoad(47-49)src/renderer/src/components/Cosmetics/Badges.jsx (12)
mousePos(7-7)mousePos(52-52)mousePos(102-102)handleMouseEnter(13-16)handleMouseEnter(54-57)handleMouseEnter(104-107)handleMouseLeave(18-20)handleMouseLeave(59-61)handleMouseLeave(109-111)handleMouseMove(22-29)handleMouseMove(63-70)handleMouseMove(113-120)
utils/services/connectionManager.js (6)
src/renderer/src/components/Chat/index.jsx (1)
chatroom(17-17)src/renderer/src/components/Chat/Input/index.jsx (3)
chatroom(1166-1166)emote(894-896)tracer(53-53)src/renderer/src/providers/ChatProvider.jsx (2)
chatrooms(379-382)tracer(53-53)utils/services/seventv/stvAPI.js (3)
globalResponse(14-14)emoteGlobalData(20-20)formattedGlobalEmotes(10-10)utils/services/seventv/sharedStvWebSocket.js (1)
tracer(8-8)utils/services/kick/sharedKickPusher.js (1)
tracer(2-2)
src/renderer/src/telemetry/webTracing.js (1)
src/telemetry/tracing.js (1)
telemetryEnabled(12-12)
src/renderer/src/providers/CosmeticsProvider.jsx (2)
src/renderer/src/providers/ChatProvider.jsx (4)
getRendererTracer(48-49)startSpan(51-67)tracer(53-53)span(55-55)utils/services/seventv/sharedStvWebSocket.js (2)
tracer(8-8)INVALID_7TV_NULL_ID(5-5)
src/renderer/src/providers/ChatProvider.jsx (7)
utils/services/seventv/stvAPI.js (1)
sendUserPresence(100-127)src/renderer/src/components/Navbar.jsx (2)
orderedChatrooms(21-23)chatrooms(20-20)src/renderer/src/components/Chat/index.jsx (1)
chatroom(17-17)src/renderer/src/components/SplitPaneChat.jsx (1)
chatroom(7-7)src/renderer/src/components/Chat/Input/index.jsx (7)
chatroom(1166-1166)span(55-55)startSpan(51-67)endSpanOk(69-72)emote(894-896)error(987-987)endSpanError(74-77)src/renderer/src/providers/CosmeticsProvider.jsx (2)
span(11-11)startSpan(7-19)src/preload/index.js (1)
personalEmoteSets(112-112)
utils/services/seventv/sharedStvWebSocket.js (2)
src/renderer/src/providers/CosmeticsProvider.jsx (2)
INVALID_7TV_NULL_ID(21-21)tracer(9-9)src/renderer/src/telemetry/webTracing.js (6)
body(1735-1735)tracer(1409-1409)tracer(1717-1717)tracer(1767-1767)tracer(1879-1879)tracer(2125-2125)
🪛 dotenv-linter (3.3.0)
.env.example
[warning] 94-94: [UnorderedKey] The MAIN_VITE_GRAFANA_TEMPO_QUERY_TOKEN key should go before the MAIN_VITE_GRAFANA_TEMPO_QUERY_URL key
(UnorderedKey)
[warning] 97-97: [EndingBlankLine] No blank line at the end of the file
(EndingBlankLine)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: test-builds (windows-latest)
🔇 Additional comments (4)
electron.vite.config.mjs (1)
75-75: LGTM! Sourcemap support improves debugging.Enabling renderer source maps aligns with the updated Debug Renderer Process configuration in
.vscode/launch.json(webRoot now points tosrc/renderer/src), providing better stack traces and breakpoint resolution during development..env.example (1)
94-97: LGTM! Debug flags section improves discoverability.The reorganization and addition of debug flags documentation (e.g.,
VITE_DEBUG_7TV_WS) helps developers quickly enable troubleshooting features. The static analysis warnings about key ordering and trailing blank lines are style preferences and don't impact functionality..vscode/launch.json (1)
23-23: LGTM! Debugging path now matches actual source structure.The updated
webRootcorrectly points to the renderer source directory (src/renderer/src), ensuring accurate breakpoint resolution and stack traces when using the newly enabled source maps.src/renderer/src/assets/styles/components/Chat/Message.scss (1)
1038-1072: LGTM! Progressive loading styles improve UX.The new
.emote-placeholderand.emote-progressiveclasses provide smooth opacity transitions and visual feedback during emote loading, preventing layout shifts and clearly indicating error states. The styling aligns well with the progressive loading implementation inEmote.jsx.
| const objectKind = body?.object?.kind; | ||
|
|
||
| if (objectKind === "EMOTE_SET") { | ||
| // Handle personal emote set entitlement grants | ||
| get().handlePersonalEmoteSetEntitlement(body, "create"); | ||
| } else if (objectKind === "BADGE" || objectKind === "PAINT") { | ||
| // Handle badge/paint cosmetic entitlements | ||
| const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; | ||
| const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); | ||
| useCosmeticsStore?.getState()?.addUserStyle(transformedUsername, body); | ||
| } else { | ||
| // Log unhandled objectKind to telemetry | ||
| const span = startSpan('seventv.unhandled_entitlement_create'); | ||
| span?.setAttributes?.({ | ||
| 'entitlement.object_kind': objectKind || 'unknown', | ||
| 'entitlement.user_id': body?.object?.user?.id || 'unknown', | ||
| 'entitlement.ref_id': body?.object?.ref_id || 'unknown' | ||
| }); | ||
| span?.end?.(); | ||
| } | ||
| break; | ||
| } | ||
| case "entitlement.delete": { | ||
| const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; | ||
| const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); | ||
| useCosmeticsStore?.getState()?.removeUserStyle(transformedUsername, body); | ||
| const objectKind = body?.object?.kind; | ||
|
|
||
| if (objectKind === "EMOTE_SET") { | ||
| // Handle personal emote set entitlement revocations | ||
| get().handlePersonalEmoteSetEntitlement(body, "delete"); | ||
| } else if (objectKind === "BADGE" || objectKind === "PAINT") { | ||
| // Handle badge/paint cosmetic entitlement removals | ||
| const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; | ||
| const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); | ||
| useCosmeticsStore?.getState()?.removeUserStyle(transformedUsername, body); | ||
| } else { | ||
| // Log unhandled objectKind to telemetry | ||
| const span = startSpan('seventv.unhandled_entitlement_delete'); | ||
| span?.setAttributes?.({ | ||
| 'entitlement.object_kind': objectKind || 'unknown', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent entitlement handler crash when Kick username is absent
If 7TV emits an entitlement for a user that lacks a Kick connection, username will be undefined. The expression username?.replaceAll("-", "_").toLowerCase() only guards the replaceAll call—toLowerCase() still runs on undefined, throwing a TypeError and aborting the shared socket pipeline. The same risk exists in the delete branch. Please normalize defensively before lowercasing.
- const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username;
- const transformedUsername = username?.replaceAll("-", "_").toLowerCase();
+ const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username;
+ const transformedUsername =
+ typeof username === "string" ? username.replaceAll("-", "_").toLowerCase() : undefined;
useCosmeticsStore?.getState()?.addUserStyle(transformedUsername, body);- const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username;
- const transformedUsername = username?.replaceAll("-", "_").toLowerCase();
+ const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username;
+ const transformedUsername =
+ typeof username === "string" ? username.replaceAll("-", "_").toLowerCase() : undefined;
useCosmeticsStore?.getState()?.removeUserStyle(transformedUsername, body);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const objectKind = body?.object?.kind; | |
| if (objectKind === "EMOTE_SET") { | |
| // Handle personal emote set entitlement grants | |
| get().handlePersonalEmoteSetEntitlement(body, "create"); | |
| } else if (objectKind === "BADGE" || objectKind === "PAINT") { | |
| // Handle badge/paint cosmetic entitlements | |
| const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; | |
| const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); | |
| useCosmeticsStore?.getState()?.addUserStyle(transformedUsername, body); | |
| } else { | |
| // Log unhandled objectKind to telemetry | |
| const span = startSpan('seventv.unhandled_entitlement_create'); | |
| span?.setAttributes?.({ | |
| 'entitlement.object_kind': objectKind || 'unknown', | |
| 'entitlement.user_id': body?.object?.user?.id || 'unknown', | |
| 'entitlement.ref_id': body?.object?.ref_id || 'unknown' | |
| }); | |
| span?.end?.(); | |
| } | |
| break; | |
| } | |
| case "entitlement.delete": { | |
| const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; | |
| const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); | |
| useCosmeticsStore?.getState()?.removeUserStyle(transformedUsername, body); | |
| const objectKind = body?.object?.kind; | |
| if (objectKind === "EMOTE_SET") { | |
| // Handle personal emote set entitlement revocations | |
| get().handlePersonalEmoteSetEntitlement(body, "delete"); | |
| } else if (objectKind === "BADGE" || objectKind === "PAINT") { | |
| // Handle badge/paint cosmetic entitlement removals | |
| const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; | |
| const transformedUsername = username?.replaceAll("-", "_").toLowerCase(); | |
| useCosmeticsStore?.getState()?.removeUserStyle(transformedUsername, body); | |
| } else { | |
| // Log unhandled objectKind to telemetry | |
| const span = startSpan('seventv.unhandled_entitlement_delete'); | |
| span?.setAttributes?.({ | |
| 'entitlement.object_kind': objectKind || 'unknown', | |
| const objectKind = body?.object?.kind; | |
| if (objectKind === "EMOTE_SET") { | |
| // Handle personal emote set entitlement grants | |
| get().handlePersonalEmoteSetEntitlement(body, "create"); | |
| } else if (objectKind === "BADGE" || objectKind === "PAINT") { | |
| // Handle badge/paint cosmetic entitlements | |
| const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; | |
| const transformedUsername = | |
| typeof username === "string" ? username.replaceAll("-", "_").toLowerCase() : undefined; | |
| useCosmeticsStore?.getState()?.addUserStyle(transformedUsername, body); | |
| } else { | |
| // Log unhandled objectKind to telemetry | |
| const span = startSpan('seventv.unhandled_entitlement_create'); | |
| span?.setAttributes?.({ | |
| 'entitlement.object_kind': objectKind || 'unknown', | |
| 'entitlement.user_id': body?.object?.user?.id || 'unknown', | |
| 'entitlement.ref_id': body?.object?.ref_id || 'unknown' | |
| }); | |
| span?.end?.(); | |
| } | |
| break; | |
| } | |
| case "entitlement.delete": { | |
| const objectKind = body?.object?.kind; | |
| if (objectKind === "EMOTE_SET") { | |
| // Handle personal emote set entitlement revocations | |
| get().handlePersonalEmoteSetEntitlement(body, "delete"); | |
| } else if (objectKind === "BADGE" || objectKind === "PAINT") { | |
| // Handle badge/paint cosmetic entitlement removals | |
| const username = body?.object?.user?.connections?.find((c) => c.platform === "KICK")?.username; | |
| const transformedUsername = | |
| typeof username === "string" ? username.replaceAll("-", "_").toLowerCase() : undefined; | |
| useCosmeticsStore?.getState()?.removeUserStyle(transformedUsername, body); | |
| } else { | |
| // Log unhandled objectKind to telemetry | |
| const span = startSpan('seventv.unhandled_entitlement_delete'); | |
| span?.setAttributes?.({ | |
| 'entitlement.object_kind': objectKind || 'unknown', | |
| 'entitlement.user_id': body?.object?.user?.id || 'unknown', | |
| 'entitlement.ref_id': body?.object?.ref_id || 'unknown' | |
| }); | |
| span?.end?.(); | |
| } | |
| break; | |
| } |
🤖 Prompt for AI Agents
In src/renderer/src/providers/ChatProvider.jsx around lines 1897 to 1934, the
entitlement handlers call username?.replaceAll("-", "_").toLowerCase() which can
still call toLowerCase on undefined and crash when the user has no Kick
connection; fix by normalizing the username before further transforms (e.g.
const username = body?.object?.user?.connections?.find(c => c.platform ===
"KICK")?.username ?? ""; const transformedUsername = username.replaceAll("-",
"_").toLowerCase();) and apply the same defensive normalization in both the
create and delete branches so removeUserStyle/addUserStyle always receive a
string.
| let rgbaColor = typeof shadow.color === "string" ? shadow.color : argbToRgba(shadow.color); | ||
| if (!rgbaColor) return null; | ||
| rgbaColor = rgbaColor.replace(/rgba\((\d+), (\d+), (\d+), ([0-9.]+)\)/, "rgba($1, $2, $3)"); | ||
| return `drop-shadow(${rgbaColor} ${shadow.x_offset}px ${shadow.y_offset}px ${shadow.radius}px)`; | ||
| }) | ||
| .filter(Boolean) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ensure drop-shadow colors remain valid CSS.
The new shadow logic rewrites rgba(r, g, b, a) into rgba(r, g, b), which is invalid syntax and causes CSS parsing failures (the shadow disappears whenever the source had transparency). Please convert to rgb(...) or keep the alpha component intact so we emit a valid color string.
- rgbaColor = rgbaColor.replace(/rgba\((\d+), (\d+), (\d+), ([0-9.]+)\)/, "rgba($1, $2, $3)");
+ rgbaColor = rgbaColor.replace(
+ /rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/,
+ (_match, r, g, b, a) => `rgba(${r}, ${g}, ${b}, ${a})`
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let rgbaColor = typeof shadow.color === "string" ? shadow.color : argbToRgba(shadow.color); | |
| if (!rgbaColor) return null; | |
| rgbaColor = rgbaColor.replace(/rgba\((\d+), (\d+), (\d+), ([0-9.]+)\)/, "rgba($1, $2, $3)"); | |
| return `drop-shadow(${rgbaColor} ${shadow.x_offset}px ${shadow.y_offset}px ${shadow.radius}px)`; | |
| }) | |
| .filter(Boolean) | |
| let rgbaColor = typeof shadow.color === "string" | |
| ? shadow.color | |
| : argbToRgba(shadow.color); | |
| if (!rgbaColor) return null; | |
| rgbaColor = rgbaColor.replace( | |
| /rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)/, | |
| (_match, r, g, b, a) => `rgba(${r}, ${g}, ${b}, ${a})` | |
| ); | |
| return `drop-shadow(${rgbaColor} ${shadow.x_offset}px ${shadow.y_offset}px ${shadow.radius}px)`; |
🤖 Prompt for AI Agents
In src/renderer/src/providers/CosmeticsProvider.jsx around lines 102-107, the
current code strips the alpha from "rgba(r, g, b, a)" producing invalid "rgba(r,
g, b)" CSS; update the logic so emitted drop-shadow colors are valid by handling
the alpha: if alpha is 1 emit "rgb(r, g, b)", otherwise keep the alpha and emit
"rgba(r, g, b, a)"; implement this by parsing the rgba capture groups and
conditionally formatting the replacement string (or simply avoid removing the
alpha entirely) before building the drop-shadow value.
| console.error = window.__KT_ORIGINAL_CONSOLE_ERROR__; | ||
| console.warn = window.__KT_ORIGINAL_CONSOLE_WARN__; | ||
| console.log('[Console Instrumentation]: Telemetry disabled - restored native console methods'); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reset console instrumentation state when disabling telemetry.
When telemetry is toggled off, we restore the original console methods but leave window.__KT_CONSOLE_INSTRUMENTED__ truthy. Subsequent re-enables skip re-installing the wrappers, so console telemetry never comes back after the first disable. Please clear the instrumentation flag (and cached originals) when restoring the native handlers.
if (window.__KT_ORIGINAL_CONSOLE_ERROR__ && console.error !== window.__KT_ORIGINAL_CONSOLE_ERROR__) {
console.error = window.__KT_ORIGINAL_CONSOLE_ERROR__;
console.warn = window.__KT_ORIGINAL_CONSOLE_WARN__;
console.log('[Console Instrumentation]: Telemetry disabled - restored native console methods');
+ delete window.__KT_ORIGINAL_CONSOLE_ERROR__;
+ delete window.__KT_ORIGINAL_CONSOLE_WARN__;
+ delete window.__KT_CONSOLE_INSTRUMENTED__;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| console.error = window.__KT_ORIGINAL_CONSOLE_ERROR__; | |
| console.warn = window.__KT_ORIGINAL_CONSOLE_WARN__; | |
| console.log('[Console Instrumentation]: Telemetry disabled - restored native console methods'); | |
| } | |
| if (window.__KT_ORIGINAL_CONSOLE_ERROR__ && console.error !== window.__KT_ORIGINAL_CONSOLE_ERROR__) { | |
| console.error = window.__KT_ORIGINAL_CONSOLE_ERROR__; | |
| console.warn = window.__KT_ORIGINAL_CONSOLE_WARN__; | |
| console.log('[Console Instrumentation]: Telemetry disabled - restored native console methods'); | |
| delete window.__KT_ORIGINAL_CONSOLE_ERROR__; | |
| delete window.__KT_ORIGINAL_CONSOLE_WARN__; | |
| delete window.__KT_CONSOLE_INSTRUMENTED__; | |
| } |
🤖 Prompt for AI Agents
In src/renderer/src/telemetry/webTracing.js around lines 282 to 285, when
telemetry is disabled the code restores original console methods but leaves
window.__KT_CONSOLE_INSTRUMENTED__ and the cached originals set, causing
re-enables to skip re-installing wrappers; update the disable path to also clear
window.__KT_CONSOLE_INSTRUMENTED__ (set to false or delete) and remove cached
originals (delete window.__KT_ORIGINAL_CONSOLE_ERROR__,
window.__KT_ORIGINAL_CONSOLE_WARN__, and any other __KT_ORIGINAL_CONSOLE_*
entries) so that re-enabling telemetry correctly reinstalls console
instrumentation.
| if (process.env.NODE_ENV === 'development') { | ||
| try { | ||
| const testTracer = trace.getTracer('kicktalk-renderer'); | ||
| const s = testTracer.startSpan('renderer_export_smoke'); | ||
| s.end(); | ||
| if (typeof provider.forceFlush === 'function') { | ||
| provider.forceFlush().catch(() => {}); | ||
| } | ||
| } catch {} | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid process.env in renderer; use Vite env guard instead.
Accessing process.env.NODE_ENV in the renderer breaks when Node globals are unavailable (our build disables direct Node APIs per renderer guidelines), causing a ReferenceError that short-circuits telemetry initialisation in production. Please gate the dev-only span emission via import.meta.env.DEV (or a similar renderer-safe flag) instead. As per coding guidelines.
- if (process.env.NODE_ENV === 'development') {
+ if (import.meta.env.DEV) {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (process.env.NODE_ENV === 'development') { | |
| try { | |
| const testTracer = trace.getTracer('kicktalk-renderer'); | |
| const s = testTracer.startSpan('renderer_export_smoke'); | |
| s.end(); | |
| if (typeof provider.forceFlush === 'function') { | |
| provider.forceFlush().catch(() => {}); | |
| } | |
| } catch {} | |
| } | |
| if (import.meta.env.DEV) { | |
| try { | |
| const testTracer = trace.getTracer('kicktalk-renderer'); | |
| const s = testTracer.startSpan('renderer_export_smoke'); | |
| s.end(); | |
| if (typeof provider.forceFlush === 'function') { | |
| provider.forceFlush().catch(() => {}); | |
| } | |
| } catch {} | |
| } |
🤖 Prompt for AI Agents
In src/renderer/src/telemetry/webTracing.js around lines 1306 to 1315, the code
uses process.env.NODE_ENV which can throw in the renderer when Node globals are
disabled; replace that check with the renderer-safe Vite guard
(import.meta.env.DEV) so the dev-only tracer smoke span is only emitted when
running in dev. Change the conditional to use import.meta.env.DEV, keep the
try/catch and forceFlush logic identical, and ensure no other references to
process.env are used in this block so telemetry init won't crash in production.
| const stvEmoteSetId = chatroom?.channel7TVEmotes?.[1]?.setInfo?.id || "0"; | ||
| this.stvWebSocket.addChatroom(chatroom.id, chatroom.streamerData.user_id, stvId, stvEmoteSetId); | ||
|
|
||
| // Fetch initial data | ||
| await this.fetchInitialMessages(chatroom); | ||
| await this.fetchInitialChatroomInfo(chatroom); | ||
|
|
||
| // Fetch emotes in background (non-blocking) | ||
| this.fetchChatroomEmotes(chatroom).catch(error => { | ||
| console.warn(`[ConnectionManager] Background emote fetch failed for ${chatroom.username}:`, error); | ||
| }); | ||
|
|
||
| // Mark as loaded | ||
| this.loadedChatrooms.add(chatroomId); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Recover 7TV IDs via the same channel-emote lookup used elsewhere.
Here we fall back to streamerData?.user?.stv_id and channel7TVEmotes?.[1] when wiring the shared 7TV socket. In practice the channel set is not guaranteed to sit at index 1, so we frequently send "0" for both IDs, skipping cosmetics/mention subscriptions for lazily loaded rooms. Please mirror the addChatroom logic (find((set) => set.type === "channel")) so we always pick up the real set/user IDs.
- const stvId = chatroom.streamerData?.user?.stv_id || "0";
- const stvEmoteSetId = chatroom?.channel7TVEmotes?.[1]?.setInfo?.id || "0";
+ const channelEmoteSet = Array.isArray(chatroom.channel7TVEmotes)
+ ? chatroom.channel7TVEmotes.find((set) => set.type === "channel")
+ : null;
+ const stvId = channelEmoteSet?.user?.id || "0";
+ const stvEmoteSetId = channelEmoteSet?.setInfo?.id || "0";
this.stvWebSocket.addChatroom(chatroom.id, chatroom.streamerData.user_id, stvId, stvEmoteSetId);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const stvEmoteSetId = chatroom?.channel7TVEmotes?.[1]?.setInfo?.id || "0"; | |
| this.stvWebSocket.addChatroom(chatroom.id, chatroom.streamerData.user_id, stvId, stvEmoteSetId); | |
| // Fetch initial data | |
| await this.fetchInitialMessages(chatroom); | |
| await this.fetchInitialChatroomInfo(chatroom); | |
| // Fetch emotes in background (non-blocking) | |
| this.fetchChatroomEmotes(chatroom).catch(error => { | |
| console.warn(`[ConnectionManager] Background emote fetch failed for ${chatroom.username}:`, error); | |
| }); | |
| // Mark as loaded | |
| this.loadedChatrooms.add(chatroomId); | |
| // Recover 7TV channel emote set (instead of assuming index 1) | |
| const channelEmoteSet = Array.isArray(chatroom.channel7TVEmotes) | |
| ? chatroom.channel7TVEmotes.find((set) => set.type === "channel") | |
| : null; | |
| const stvId = channelEmoteSet?.user?.id || "0"; | |
| const stvEmoteSetId = channelEmoteSet?.setInfo?.id || "0"; | |
| this.stvWebSocket.addChatroom( | |
| chatroom.id, | |
| chatroom.streamerData.user_id, | |
| stvId, | |
| stvEmoteSetId | |
| ); | |
| // Fetch initial data | |
| await this.fetchInitialMessages(chatroom); | |
| await this.fetchInitialChatroomInfo(chatroom); | |
| // Fetch emotes in background (non-blocking) | |
| this.fetchChatroomEmotes(chatroom).catch(error => { | |
| console.warn( | |
| `[ConnectionManager] Background emote fetch failed for ${chatroom.username}:`, | |
| error | |
| ); | |
| }); | |
| // Mark as loaded | |
| this.loadedChatrooms.add(chatroomId); |
| return { | ||
| totalDeferredChatrooms: this.deferredChatrooms.length, | ||
| loadedChatrooms: this.loadedChatrooms.size, | ||
| pendingChatrooms: this.deferredChatrooms.length - this.loadedChatrooms.size | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix pending-count math for deferred chatrooms.
pendingChatrooms = deferred.length - loadedChatrooms.size goes negative once the loaded set contains eagerly-initialized rooms (or when deferred is empty but we’ve already loaded some). This breaks the UI counters for background loads. Please intersect loadedChatrooms with the deferred IDs (and clamp at ≥0) so the status reflects only deferred rooms.
- return {
- totalDeferredChatrooms: this.deferredChatrooms.length,
- loadedChatrooms: this.loadedChatrooms.size,
- pendingChatrooms: this.deferredChatrooms.length - this.loadedChatrooms.size
- };
+ const deferredIds = new Set(this.deferredChatrooms.map((room) => room.id));
+ const loadedWithinDeferred = Array.from(this.loadedChatrooms).filter((id) => deferredIds.has(id)).length;
+ const totalDeferred = deferredIds.size;
+ return {
+ totalDeferredChatrooms: totalDeferred,
+ loadedChatrooms: loadedWithinDeferred,
+ pendingChatrooms: Math.max(totalDeferred - loadedWithinDeferred, 0),
+ };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| return { | |
| totalDeferredChatrooms: this.deferredChatrooms.length, | |
| loadedChatrooms: this.loadedChatrooms.size, | |
| pendingChatrooms: this.deferredChatrooms.length - this.loadedChatrooms.size | |
| }; | |
| // Only count loaded chatrooms that were originally deferred, | |
| // and clamp pending count at zero. | |
| const deferredIds = new Set(this.deferredChatrooms.map((room) => room.id)); | |
| const loadedWithinDeferred = Array.from(this.loadedChatrooms) | |
| .filter((id) => deferredIds.has(id)).length; | |
| const totalDeferred = deferredIds.size; | |
| return { | |
| totalDeferredChatrooms: totalDeferred, | |
| loadedChatrooms: loadedWithinDeferred, | |
| pendingChatrooms: Math.max(totalDeferred - loadedWithinDeferred, 0), | |
| }; |
🤖 Prompt for AI Agents
In utils/services/connectionManager.js around lines 915-919, the
pendingChatrooms math currently subtracts the entire loadedChatrooms.size from
deferredChatrooms.length which can go negative; instead compute how many
deferred IDs are already loaded by intersecting deferredChatrooms with
loadedChatrooms (e.g. count deferred IDs for which loadedChatrooms.has(id)),
subtract that intersection count from deferredChatrooms.length, and clamp the
result with Math.max(0, ...) so pendingChatrooms never becomes negative.
Summary
Implements lazy loading for chatrooms with automatic background initialization to achieve fast startup while preserving mention notifications.
Performance Impact:
Changes
initializeDeferredChatroomsInBackground()processes chatrooms in batchesinitializeChatroomLazily()handles single chatroom initializationloadedChatroomsSet prevents duplicate initializationTest Results
From localhost logs with 19 chatrooms:
Test Plan
Summary by CodeRabbit
New Features
Refactor
Chores